Repository Public
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// /api/admin/submissions/[id] — workflow d'une demande depuis le panel.3// PATCH { status | priority | confidentiality | assigned_to | tags | is_read4// | is_spam | is_archived } — chaque changement est journalisé dans5// l'historique d'activité. Contrôle d'accès PAR demande (anti-IDOR) : l'ID6// vient de l'URL mais le droit est revérifié serveur à chaque appel.7import { NextRequest, NextResponse } from "next/server";8import {9 findSubmissionById,10 updateSubmission,11 markResolved,12 logEvent,13 findAdminById,14} from "@/lib/comms/db";15import {16 getAdminSession,17 canViewSubmission,18 isSuperAdmin,19 CATEGORY_ROLE,20} from "@/lib/comms/admin-auth";21import {22 statusesFor,23 TERMINAL_STATUSES,24 PRIORITIES,25 CONFIDENTIALITY_LEVELS,26} from "@/lib/comms/categories";2728export async function PATCH(29 req: NextRequest,30 { params }: { params: Promise<{ id: string }> },31) {32 const session = await getAdminSession();33 if (!session)34 return NextResponse.json({ error: "Non autorisé." }, { status: 401 });35 const id = Number((await params).id);36 const sub = Number.isInteger(id) ? findSubmissionById(id) : undefined;37 if (!sub || !canViewSubmission(session, sub))38 // 404 (et non 403) : ne pas révéler l'existence d'une demande interdite.39 return NextResponse.json({ error: "Demande introuvable." }, { status: 404 });4041 const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;42 if (!body)43 return NextResponse.json({ error: "Requête illisible." }, { status: 400 });4445 const patch: Record<string, unknown> = {};46 const events: [string, string][] = [];4748 if (typeof body.status === "string") {49 const allowed = statusesFor(sub.category);50 if (!allowed[body.status])51 return NextResponse.json({ error: "Statut invalide." }, { status: 422 });52 patch.status = body.status;53 events.push(["statut", `${allowed[sub.status] ?? sub.status} → ${allowed[body.status]}`]);54 }55 if (typeof body.priority === "string") {56 if (!(body.priority in PRIORITIES))57 return NextResponse.json({ error: "Priorité invalide." }, { status: 422 });58 patch.priority = body.priority;59 events.push(["priorite", `${sub.priority} → ${body.priority}`]);60 }61 if (typeof body.confidentiality === "string") {62 if (!(body.confidentiality in CONFIDENTIALITY_LEVELS))63 return NextResponse.json({ error: "Niveau invalide." }, { status: 422 });64 // Relever/abaisser la confidentialité d'une catégorie sensible :65 // super_admin ou rôle dédié seulement.66 const dedicated = CATEGORY_ROLE[sub.category];67 if (68 body.confidentiality === "restreinte" &&69 !isSuperAdmin(session) &&70 !(dedicated && session.roles.includes(dedicated))71 )72 return NextResponse.json(73 { error: "Seul le rôle dédié peut restreindre cette demande." },74 { status: 403 },75 );76 patch.confidentiality = body.confidentiality;77 events.push(["confidentialite", `${sub.confidentiality} → ${body.confidentiality}`]);78 }79 if (body.assigned_to !== undefined) {80 if (body.assigned_to === null) {81 patch.assigned_to = null;82 events.push(["assignation", "Demande désassignée"]);83 } else {84 const target = findAdminById(Number(body.assigned_to));85 if (!target || !target.active)86 return NextResponse.json({ error: "Admin inconnu." }, { status: 422 });87 patch.assigned_to = target.id;88 events.push(["assignation", `Assignée à ${target.display_name}`]);89 }90 }91 if (Array.isArray(body.tags)) {92 const tags = body.tags93 .filter((t): t is string => typeof t === "string")94 .map((t) => t.trim().slice(0, 40))95 .filter(Boolean)96 .slice(0, 12);97 patch.tags = JSON.stringify(tags);98 events.push(["tags", tags.join(", ") || "(aucun)"]);99 }100 for (const flag of ["is_read", "is_spam", "is_archived"] as const) {101 if (typeof body[flag] === "boolean") {102 patch[flag] = body[flag] ? 1 : 0;103 if (flag !== "is_read")104 events.push([105 flag === "is_spam" ? "spam" : "archive",106 body[flag] ? "activé" : "retiré",107 ]);108 }109 }110111 if (!Object.keys(patch).length)112 return NextResponse.json({ error: "Rien à modifier." }, { status: 400 });113114 updateSubmission(sub.id, patch);115 if (typeof patch.status === "string")116 markResolved(sub.id, TERMINAL_STATUSES.has(patch.status));117 for (const [kind, detail] of events) logEvent(sub.id, session.id, kind, detail);118 return NextResponse.json({ ok: true });119}120